Compsci 184 Hw03
Maodun Zhang 3035626112
Haoqing Xuan 3036149505
Part 1
Ray generation
Ray generation is the first step in the ray tracing process. In this step, for each pixel of the image,
a ray is generated that originates from the camera's viewpoint (eye position) and passes through a
point on the virtual image plane. This plane is positioned in the scene at a distance that is usually
equal to the focal length of the camera. The direction of each ray depends on the camera's field
of view, aspect ratio, and the relative position of the pixel within the image frame.
To generate a ray, we first convert the pixel coordinates to normalized device coordinates (NDC),
which range from -1 to 1, with (0,0) being the center of the image. Then, we adjust these
coordinates based on the aspect ratio and the field of view to find the corresponding point on the
image plane in camera space. This point is transformed into world space using the camera's
transformation matrix. Finally, a ray is defined by its origin (the camera's position) and its
direction (from the camera's position to the transformed point on the image plane).
Steps of ray generation:
Step 1: Convert the normalized image coordinates to screen space coordinates
Step 2: Adjust these screen space coordinates according to the camera's field of view
Step 3: Set the z-coordinate in camera space to -1 and normalize the direction vector
Step 4: Transform the direction vector from camera space to world space using the c2w matrix
Step 5: Create a Ray object with the camera's position as the origin and the transformed direction
as the direction
banana.png (part 1.1 & 1.2)
CBempty.png (part 1.1 & 1.2)
CBspheres.png (full part 1)
CBempty.png (full part 1)
Triangle intersection algorithm
When we are given a ray, we calculate vectors e1 and e2 between the triangle's vertices p1, p2,
and p3, and we then compute the vectors s, s1, and s2 to aid in the intersection calculation.
Using cross products and dot products, we determine coefficients t, b1, b2, and b3 to check if the
intersection point lies within the triangle and along the ray's direction. If the intersection point is
within the triangle and within the ray's bounds (t within r.min_t and r.max_t), we will update the
intersection. And we use Barycentric coordinates to interpolate the normal at the intersection
point. We also update the intersection structure with the intersection details and return true if an
intersection is found, otherwise false.
Sphere intersection algorithm:
When we are given a ray, we calculate the vector oc from the sphere's center o to the ray's origin.
We compute the coefficients for the quadratic equation representing the intersection between the
ray and the sphere, and if the discriminant (b^2 - 4ac) is negative, there are no real roots,
indicating that the ray does not intersect the sphere. We then calculate the two roots of the
quadratic equation using the quadratic formula and store the smaller root in t1 and the larger root
in t2, and return true to indicate that the ray intersects the sphere. If there are intersections, we
choose the smaller valid intersection point within the ray's bounds (r.min_t and r.max_t) and
update the intersection structure with the intersection details. And finally, we update the ray's
max_t to the closest intersection point and return true to indicate a valid intersection.
Part 2
BVH construction algorithm
1. Compute Bounding Box:
The algorithm starts by computing a bounding box (BBox) that encloses all the primitives
(e.g., triangles, spheres) in the current node. This is done by iterating over all primitives
in the range [start, end) and expanding the bounding box to include each primitive's
bounding box.
2. Node Creation:
A new BVH node is created with the computed bounding box.
3. Leaf or Interior Node Determination:
The algorithm checks if the number of primitives in the current node is less than or equal
to a specified max_leaf_size. If true, this node becomes a leaf node, and it directly stores
the range of primitives. If false, the node is an interior node and needs to be split.
4. Splitting Criteria:
Finding Longest Axis: The algorithm calculates the extents of the bounding box along
each axis (x, y, z). It then identifies the longest axis. This is crucial for determining where
to split the node.
Midpoint Splitting: The split position is chosen based on the midpoint of the longest axis.
The idea is to divide the primitives into two groups, each occupying a sub-volume of the
current node's volume.
5. Partitioning Primitives:
The primitives are partitioned around this split position. A standard std::partition
algorithm is used, which rearranges the primitives so that all primitives whose centroids
are less than the split position along the chosen axis come before all other primitives.
6. Handling Edge Cases:
If all primitives end up on one side of the split (which can happen if they are very
unevenly distributed), the algorithm falls back to a simple median split. This avoids
creating highly unbalanced trees.
7. Recursive Construction:
The function then recursively calls itself for the two sets of primitives (those before and
after the partitioning point), creating left and right child nodes.
8. Return Node:
Finally, the constructed node (which now has a bounding box, and potentially left and
right children) is returned.
Heuristic for Splitting
The heuristic used here for picking the splitting point is the midpoint of the longest axis of the
bounding box. This is a common and effective heuristic in BVH construction. The choice of the
longest axis helps to ensure that the split will, on average, create more balanced child volumes,
especially for elongated shapes. The midpoint is a simple yet often effective choice for the split
position as it tends to divide the primitives fairly evenly, at least spatially.
This heuristic is a balance between computational efficiency and tree quality. More complex
methods (like Surface Area Heuristic, SAH) can create more optimized trees but at the cost of
higher construction times. The midpoint heuristic provides a good compromise, especially in
cases where the overhead of more complex methods is not justified.
dae/sky/CBlucy.dae
dae/meshedit/maxplanck.dae
dae/sky/dragon.dae
Rendering Time Difference Analysis
The rendering times for the three scenes with moderately complex geometries illustrate the
significant efficiency gains provided by Bounding Volume Hierarchy (BVH) acceleration. For
the "dragon.dae" scene, using BVH acceleration reduced the rendering time from 122.3147
seconds to just 0.0336 seconds, a dramatic decrease that highlights the efficiency of BVH in
handling complex geometry. Similarly, for the "CBlucy.dae" scene, the rendering time dropped
from 85.7079 seconds without BVH to 0.0407 seconds with BVH, demonstrating the
acceleration's effectiveness in optimizing rendering processes. The "maxplanck.dae" scene also
shows considerable improvement, reducing rendering time from 38.0238 seconds to 0.0391
seconds when employing BVH acceleration. These results indicate that BVH acceleration can
drastically reduce rendering times, especially in scenes with complex geometries. This
acceleration is likely due to the efficient spatial subdivision and reduced number of intersection
tests that BVH offers, as it allows the rendering algorithm to quickly disregard large volumes of
irrelevant geometry for each ray. The consistency of the results across different scenes reaffirms
that BVH acceleration is a robust optimization technique for speeding up the rendering of
complex 3D scenes.
Part 3
Walkthrough:
1. Uniform Hemisphere Sampling: This method estimates the direct lighting at an intersection
point by sampling directions uniformly in a hemisphere. It iterates over a fixed number of
samples, sampling a direction in the hemisphere, generating a ray in that direction from the hit
point, and checking for intersections with scene geometry. If an intersection occurs, it evaluates
the BSDF and accumulates the contribution from that direction. Finally, it normalizes the
accumulated result by the number of samples and the probability of each sample.
2. Light Sampling: This method estimates direct lighting by sampling from lights in the scene. It
iterates over each light and, if the light is a delta light , we samples the light, creates a shadow
ray towards the light sample, checks for intersections with scene geometry, and adds the
contribution from the light if no intersection occurs. It then normalizes the accumulated result by
dividing by the total number of lights in the scene.
3. The uniform hemisphere sampling method provides a straightforward approach to estimating
direct lighting, sampling directions uniformly in a hemisphere around the hit point. However, it
may miss some important light sources in the scene, especially if the light sources are not
uniformly distributed in all directions. Light sampling focuses specifically on light sources in the
scene, ensuring that all lights contribute to the estimation, which can lead to more accurate
results, showing a smoother image. Light sampling requires more careful handling of delta lights
and shadow rays to estimate the direct lighting contribution from each light source accurately.
dae/sky/dragon.dae
Image of using uniform hemisphere
Image of using direct light sampling
Here are the pictures rendered with different parameters
1 sample pixel, 1 light ray
1 sample pixel, 4 light rays
1 sample pixel, 16 light rays
1 sample pixel, 64 light rays
Part 4:
1. we start by computing the direct lighting contribution using the one_bounce_radiance
function, which estimates the lighting from the intersection point directly from light
sources or by sampling the hemisphere. Next, we check if the recursion depth has reached
the maximum specified by max_ray_depth. If it has, we terminate the recursion and
return the accumulated radiance. Then, we sample the BSDF of the material at the
intersection point and perform Russian roulette with a chosen termination probability. If
the termination condition is met, we terminate the recursion and return the accumulated
radiance. If Russian roulette fails, indicating that we continue the recursion, we create a
new ray in the sampled direction and recursively trace it. We accumulate the radiance
from the bounced ray and return the total accumulated radiance.
2.
3. Direct Illumination only:
Indirect Illumination:
4. Below are pictures of isAccumBounces=false and m = 0, 1, 2, 3, 4, 5 in order:
a.
b. Below are the case for isAccumBounces=true, and m = 0, 1, 2, 3, 4, 5 in order:
c. At the 2nd bounce, rays are traced from surfaces that have already been hit by the
first ray. This bounce represents indirect illumination, where light has bounced off
surfaces, which might lead to softer shadows and increased global illumination
effects. The contribution of the 2nd bounce improves the overall realism and
lighting quality of the scene.And the 3rd bounce extends the indirect illumination
further by tracing rays that bounce off surfaces already illuminated by the 2nd
bounce, which adds another layer of complexity to the lighting simulation and
results in more subtle effects like multiple reflections and refractions. As a result,
the scene becomes even more realistic. Comparing the rendered images to
rasterization, we can notice that ray tracing with multiple bounces of light
produces more accurate lighting and shadowing effects, especially in scenes with
complex geometry and materials. Ray tracing captures the behavior of light better
with more details, which may be challenging to achieve with rasterization
techniques alone.
d. Below are the pictures of using Russian Roulette with m = 0, 1, 2, 3, 4, 100
e. Below are pictures of spheres with 1, 2, 4, 8, 16, 64, 1024 sample-per-pixel rates
Part 5
In my implementation of adaptive sampling within a ray tracing algorithm, I begin by setting a
base number of samples per pixel. My goal is to dynamically adjust this number based on the
complexity of each pixel's content. Here’s how I approach it:
Initial Setup:
I start with a predefined number of samples for each pixel. I also initialize variables to keep track
of the accumulated color (avgColor) and the statistics needed for the adaptive sampling decision
(s1 and s2 for sum of illuminance and its square, respectively).
Sampling and Ray Tracing:
In a loop, I generate camera rays for each sample. For this, I calculate normalized coordinates
within the pixel, ensuring each sample covers a different part of the pixel. I trace each ray
through the scene and estimate the radiance along it. I accumulate these radiance estimates to
form the average color for the pixel, and simultaneously, I update s1 and s2 for the adaptive
sampling calculations.
Adaptive Decision:
Every few samples (as defined by samplesPerBatch), I pause to assess the variance in the
samples I've taken so far. Using s1 and s2, I calculate the mean and standard deviation of the
sample illuminances. With these, I determine the confidence interval of the mean. If this interval
is smaller than a set tolerance level (defined by maxTolerance) relative to the mean, I interpret it
as the pixel's color being sufficiently resolved. In that case, I stop sampling early for that pixel.
Finalizing Pixel Color:
Once I exit the sampling loop—either because I've reached the maximum number of samples or
decided to stop early based on the adaptive criteria—I finalize the average color for the pixel.
This involves dividing the accumulated color by the actual number of samples taken. I then
update the pixel's color in the image.
In essence, my adaptive sampling strategy allows me to focus computational resources more
effectively. Pixels in complex areas (with high detail or noise) receive more samples, while
simpler areas get fewer samples. This approach helps me achieve a good balance between image
quality and rendering efficiency. The adaptiveness is finely controlled by samplesPerBatch and
maxTolerance, which I tune based on the specific needs of the scene I'm rendering.
Rendered Images and rate images:
1. Bunny:
2. Spheres:
Summary Copilot raw.githubusercontent.com Ask a follow-up question GPT-3.5 GPT-4